iT邦幫忙

2025 iThome 鐵人賽

DAY 4
0
Security

從1到2的召喚羊駝補破網之旅系列 第 4

Day 4 :免費開源 = 要還的技術債

  • 分享至 

  • xImage
  •  

[鐵人賽] Day 4:撰寫監控腳本到,延續前輩uptime kuma卻存在明文密碼的技術債

寫在前面(心境篇)

前面寫這麼多糟糕事,應該不會更糟糕了吧?

8 月底主管特別交代,要我研究伺服器監控。
我心想:這不難嘛,以前在大同大學機房就寫過監控腳本,直接搬出來改一改就能用。

於是我決定把這段小插曲寫下來:如何在沒有 fancy 工具的情況下,利用 Bash + curl + nc 自己拼湊一套監控與告警系統。這麼有效率的寫code,「當然是交給羊駝來幫忙整理成能跑的程式碼。」。

這時候扔出寶貝球大喊「就決定是你了,阿爾宙斯」!!

https://ithelp.ithome.com.tw/upload/images/20250917/20165500sgjwDr4TBq.png

curl -v \
     --url smtp://<SMTP_HOST>:<PORT> \
     -u "<USERNAME>:<PASSWORD>" \                # (可選) 若需要驗證
     --mail-from "<SENDER_EMAIL>" \
     --mail-rcpt "<RECIPIENT_EMAIL>" \
     -T <MESSAGE_FILE>
參數 說明
-v 顯示詳細連線資訊(debug)
--url smtp://… 指定 SMTP 伺服器、port(預設 25)
-u SMTP AUTH:使用 user:pass 進行登錄
--mail-from 寄件人地址
--mail-rcpt 收件人地址(可多次使用)
-T 讀取一個檔案作為完整 MIME 信件(headers + body)

問題拆解(監控其實很簡單?)

監控不就是 keep alive 或 nc -zv 嗎?
表面上是這樣,但實務上卻一堆眉角:

  • 要通知誰? → Telegram bot?Mail?還是 Line bot?
  • 怎麼避免洗版? → 首次告警、恢復再通知。
  • 怎麼處理 API 類服務? → 不能只看 TCP,還要確保 HTTP 回應 200。
  • 換行字元坑 → Windows/Linux 混用腳本時,\r\n 一定會咬你一口。

我實作的解法(腳本三件套)

1) 舊版:Telegram 即時告警

這是我在大同大學機房使用的版本。特色是:避免洗版每5分鐘才會重複告警一次

#!/bin/bash
# send_telegram.sh (sanitized)
# 說明:主機清單不放在腳本內,改從外部檔案讀入;BOT / CHAT 為佔位符。

# 外部主機清單檔案(每行一個 host:port,例如 example.com:443)
HOSTS_FILE="${HOME}/monitor_hosts.txt"

# 檢查 host list 是否存在
if [[ ! -f "$HOSTS_FILE" ]]; then
  echo "❌ 主機清單不存在:$HOSTS_FILE"
  echo "請建立檔案,每行格式 host:port(例如 example.com:443)"
  exit 1
fi

# 載入 host list 到陣列
declare -A HOSTS
while IFS=: read -r host port; do
  # 忽略空行與註解行(以 # 開頭)
  [[ -z "$host" || "$host" =~ ^# ]] && continue
  HOSTS["$host"]=$port
done < "$HOSTS_FILE"

# 檢查是否有載入
if [[ ${#HOSTS[@]} -eq 0 ]]; then
  echo "❌ 未解析到任何主機,請檢查 $HOSTS_FILE"
  exit 1
fi

# 設定檢查間隔時間(秒)
CHECK_INTERVAL=300

# Telegram 設定(請在環境變數或 ~/.monitor_env 檔案中設定)
# BOT_TOKEN 與 CHAT_ID 不要寫在腳本裡面
BOT_TOKEN="${BOT_TOKEN:-}"
CHAT_ID="${CHAT_ID:-}"

if [[ -z "$BOT_TOKEN" || -z "$CHAT_ID" ]]; then
  echo "❌ 請先在環境變數或 ~/.monitor_env 設定 BOT_TOKEN 與 CHAT_ID"
  echo "例如: export BOT_TOKEN='xxxx'; export CHAT_ID='-100123...' "
  exit 1
fi

declare -A ALERT_SENT

timestamp() { echo "$(date +"%Y-%m-%d %H:%M:%S")"; }

send_telegram_notify() {
  local message="$1"
  curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
    -d "chat_id=${CHAT_ID}" \
    -d "parse_mode=Markdown" \
    -d "text=${message}" > /dev/null
}

while true; do
  echo "$(timestamp) 🔍 開始檢查主機狀態..."
  for HOST in "${!HOSTS[@]}"; do
    PORT=${HOSTS[$HOST]}
    if nc -zv "$HOST" "$PORT" &>/dev/null; then
      echo "$(timestamp) ✅ $HOST:$PORT open"
      if [[ ${ALERT_SENT[$HOST]} ]]; then
        send_telegram_notify "✅ *恢復正常*\n🔹 *主機:* \`$HOST\`\n🔹 *端口:* \`$PORT\`"
        unset ALERT_SENT[$HOST]
      fi
    else
      echo "$(timestamp) ❌ $HOST:$PORT down"
      if [[ -z ${ALERT_SENT[$HOST]:-} ]]; then
        send_telegram_notify "🔥 *警告! 服務異常*\n🔹 *主機:* \`$HOST\`\n🔹 *端口:* \`$PORT\`\n⚠️ 無法連線,請立即檢查!"
        ALERT_SENT[$HOST]=1
      fi
    fi
  done
  sleep "$CHECK_INTERVAL"
done


2) 新版:M365 寄信告警

主管覺得 Mail 比較直覺,於是改成 M365 SMTP。

#!/usr/bin/env bash
# send_mail.sh
# 用法:
#   ./send_mail.sh 收件人 主旨 內文

set -euo pipefail

# ==== SMTP 設定(範例用,請自行替換)====
SMTP_HOST="smtp.office365.com"
SMTP_PORT=587
FROM_ADDR="example@yourdomain.com"   # 寄件人帳號
LOGIN_USER="example@yourdomain.com"  # 登入帳號
PASS="your_password_here"            # 建議用檔案讀取,不要硬編碼

# ==== 引數 ====
TO="${1:-}"
SUBJECT="${2:-}"
BODY="${3:-}"

if [ -z "$TO" ] || [ -z "$SUBJECT" ] || [ -z "$BODY" ]; then
  echo "❌ 用法錯誤:請輸入 收件人 主旨 內文"
  exit 1
fi

# ==== 組信 ====
TMP_MAIL="$(mktemp /tmp/mail.XXXXXX)"
trap 'rm -f "$TMP_MAIL"' EXIT

{
  echo "From: ${FROM_ADDR}"
  echo "To: ${TO}"
  echo "Subject: ${SUBJECT}"
  echo "MIME-Version: 1.0"
  echo "Content-Type: text/plain; charset=UTF-8"
  echo
  echo "${BODY}"
} > "$TMP_MAIL"

# ==== 寄送 ====
curl --url "smtp://${SMTP_HOST}:${SMTP_PORT}" \
  --mail-from "${FROM_ADDR}" \
  --mail-rcpt "${TO}" \
  --upload-file "$TMP_MAIL" \
  --user "${LOGIN_USER}:${PASS}" \
  --ssl-reqd \
  --silent --show-error --verbose

echo "✅ 郵件已送出 → ${TO}"

3) 綜合版:TCP + HTTP 200

send_mail_monitor.sh改呼叫send mail 。

#!/usr/bin/env bash
# send_mail_monitor.sh
# 說明:監控多台主機的 TCP 連線(nc),並對 GEPTKids API 額外進行 HTTP 200 檢查(curl)。
# 依照「首次告警、恢復再通知」的原則發送信件,避免洗版。

set -euo pipefail

########################################
# 基本設定
########################################
MAIL_TO="fanli@o365.ttu.edu.tw"
SUBJECT_DOWN_TCP="🔥 斷線告警 - TCP"
SUBJECT_UP_TCP="✅ 恢復通知 - TCP"
SUBJECT_DOWN_HTTP="🔥 斷線告警 - HTTP"
SUBJECT_UP_HTTP="✅ 恢復通知 - HTTP"

# 主機清單(可按需增刪)
# 備註:api.xxx.org.tw 會同時做 TCP 與 HTTP 200 檢查
declare -A HOSTS=(
  ["xxx.tw"]=443
  ["api.xxx.org.tw"]=443  # GEPTKids API - Fuzzy Search
)

# 檢查間隔秒數
CHECK_INTERVAL=300

# 告警狀態記錄(同一輪程式執行期間)
# 一般主機:用 ALERT_SENT["host"] 記錄 TCP 告警
# API 主機:用 ALERT_SENT["host:tcp"] 與 ALERT_SENT["host:http"] 分別記錄兩種層級的告警
declare -A ALERT_SENT

########################################
# 公用函式
########################################
timestamp() { date +"%Y-%m-%d %H:%M:%S"; }

send_mail() {
  local to="$1" subject="$2" body="$3"
  ./send_mail.sh "$to" "$subject" "$body"
}

log() {
  # 統一輸出格式
  echo "$(timestamp) $*"
}

########################################
# 檢查:TCP 連線 (nc)
########################################
check_tcp() {
  local host="$1" port="$2"
  if nc -zv "$host" "$port" &>/dev/null; then
    log "✅ ${host} TCP ${port} is open"
    if [[ -n "${ALERT_SENT[${host}]:-}" ]]; then
      # 若之前發過異常告警,現在恢復就寄出「恢復通知」
      local body=$"主機:${host}\n端口:${port}\n檢查方式:TCP 連線\n狀態:已恢復\n時間:$(timestamp)"
      send_mail "$MAIL_TO" "$SUBJECT_UP_TCP" "$body"
      unset "ALERT_SENT[${host}]"
    fi
  else
    log "❌ ${host} TCP ${port} is closed or host is down"
    if [[ -z "${ALERT_SENT[${host}]:-}" ]]; then
      # 首次偵測到異常才寄一封,避免洗版
      local body=$"主機:${host}\n端口:${port}\n檢查方式:TCP 連線\n狀態:連線失敗\n時間:$(timestamp)\n請立即檢查。"
      send_mail "$MAIL_TO" "$SUBJECT_DOWN_TCP" "$body"
      ALERT_SENT["${host}"]=1
    fi
  fi
}

########################################
# 檢查:API HTTP 回應 (curl)
# 專給 api.geptkids.org.tw:判定 200 為正常,其餘(含逾時/失敗)視為異常
########################################
check_api_http_200() {
  local host="$1"
  local url="https://api.geptkids.org.tw/api/fuzzySearch?q=apple"

  # 超時參數避免卡住整輪檢查;失敗時統一回傳 000 方便判斷
  local http_code
  http_code=$(curl -sS -o /dev/null \
    --connect-timeout 5 --max-time 8 \
    -w "%{http_code}" "$url" || echo "000")

  if [[ "$http_code" == "200" ]]; then
    log "✅ ${host} API 回應 200"
    if [[ -n "${ALERT_SENT[${host}:http]:-}" ]]; then
      # 若之前發過異常告警,現在恢復就寄出「恢復通知」
      local body=$"主機:${host}\n檢查方式:HTTP API\n狀態:已恢復(200)\n時間:$(timestamp)"
      send_mail "$MAIL_TO" "$SUBJECT_UP_HTTP" "$body"
      unset "ALERT_SENT[${host}:http]"
    fi
  else
    log "❌ ${host} API 回應 ${http_code}"
    if [[ -z "${ALERT_SENT[${host}:http]:-}" ]]; then
      # 首次偵測到異常才寄一封,避免洗版
      local body=$"主機:${host}\n檢查方式:HTTP API\n狀態:異常(回應碼:${http_code})\n時間:$(timestamp)\n請立即檢查。"
      send_mail "$MAIL_TO" "$SUBJECT_DOWN_HTTP" "$body"
      ALERT_SENT["${host}:http"]=1
    fi
  fi
}

########################################
# 主迴圈
########################################
while true; do
  log "🔍 開始檢查主機狀態..."
  for host in "${!HOSTS[@]}"; do
    port="${HOSTS[$host]}"

    if [[ "$host" == "api.geptkids.org.tw" ]]; then
      # API 主機同時做兩種檢查:
      # 1) TCP 連線可達(保留原本 nc -zv 行為)
      # 2) HTTP 服務回應是否為 200
      # TCP 使用一般主機的 ALERT_SENT["host"] 旗標
      check_tcp "$host" "$port"

      # API 層使用獨立的 ALERT_SENT["host:http"] 旗標
      check_api_http_200 "$host"
    else
      # 其他主機維持原本 nc 檢查
      check_tcp "$host" "$port"
    fi
  done

  sleep "$CHECK_INTERVAL"
done


API 服務用 curl -w "%{http_code}" 驗證 200,其他服務則照樣用 nc -zv


我遇到的坑(又一次的老毛病)

去年踩過的坑,今年還是踩:
Windows 編輯的 shell script 拿到 Linux 跑,結果因為 \r 換行字元,整個腳本跑不動。

解法:

sed -i 's/\r$//' send_mail_monitor.sh

結果還沒幾天,主管又寄了一封信:

之前同事研究,網站的監控軟體,後來架設了 Uptime Kuma
GitHub 專案:https://github.com/louislam/uptime-kuma

我:……

真正的大坑(明文密碼事件)

本來以為只是小小的監控,結果竟然讓我發現:

  • UptimeKuma 的告警信件 → 用 公用帳號 寄出
  • 點一下「👁️」眼睛圖示 → SMTP 密碼明文顯示

我愣住了。這不就是一個 橫向移動的大門 嗎?

擁有這組帳密,代表:

  • 可以用公用帳號發送 釣魚信 / 社交工程信
  • 可以直接登入該帳號的信箱 → 讀取內部往來信件

這比 nc -zv 探測到的 port 還要嚴重一百倍。

https://ithelp.ithome.com.tw/upload/images/20250918/201655001j44lz34Ki.png


結語(Day 4 的省思)

結果和上次一樣:我發現的,比我想解決的還要更棘手。
我開始懷疑這是不是我的宿命——永遠在不經意間看到不該看的東西。

不過話說回來,這些腳本至少能留下來,讓後來人少走一點冤枉路。



上一篇
Day 3 :一本正經胡說八道
系列文
從1到2的召喚羊駝補破網之旅4
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言